Skip to content

feat(plugin): add Dameng DM8 support - #2114

Open
sophiathedev wants to merge 11 commits into
TableProApp:mainfrom
sophiathedev:feat/dm8-plugin
Open

feat(plugin): add Dameng DM8 support#2114
sophiathedev wants to merge 11 commits into
TableProApp:mainfrom
sophiathedev:feat/dm8-plugin

Conversation

@sophiathedev

@sophiathedev sophiathedev commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Adds first-class Dameng DM8 support as a downloadable macOS database-driver plugin.

Preview

TablePro confirming an external Dameng DM8 connection

What you get

  • Connect with the dm:// URL scheme and browse or switch schemas without installing a local DM client.
  • Browse tables, views, columns, indexes, and foreign keys; run queries, edit rows, use transactions, and generate DDL.
  • Get DM8-specific statements, functions, data types, schemas, tables, views, and columns while typing.
  • Inspect generated table metadata and render native DM8 EXPLAIN output in TablePro's visual plan view.
  • Build and publish the plugin for both Apple Silicon and Intel Macs.

Driver architecture and safety

The plugin uses Swift for TablePro integration, a documented C ABI for ownership boundaries, and a Rust native-wire bridge. The bridge vendors a pinned MIT-licensed rust-dameng snapshot with compatibility fixes for multi-column results, DECIMAL values, empty result sets, bounded message reads, LOB limits, and DM8's text EXPLAIN response.

Parameter substitution is SQL-state aware, escapes text values, and encodes binary values with HEXTORAW. Native panics are contained at the C boundary, response and LOB allocations are capped, row limits stop after the requested cap plus a truncation sentinel, and unrecoverable protocol failures close the connection.

Native TLS is not available yet; the documentation recommends SSH, SOCKS, or Cloudflare tunnels for untrusted networks. Native binary and off-row LOB reads remain documented limitations.

Tests

  • Latest-head macOS workflow on d5417001: all jobs passed.
  • DamengDriverTests.xctest against DM8 in OrbStack: 6 tests passed, including schema/table/view metadata, empty foreign-key results, Unicode and binary values, row caps, EXPLAIN, transactions, invalid credentials, hostile inputs, and cleanup.
  • Rust bridge and vendored protocol/type suites: 192 tests passed; the ignored OrbStack bridge workflow also passed separately.
  • Apple Silicon and Intel plugin builds: BUILD SUCCEEDED.
  • Focused TablePro registry, database-type, auto-limit, statement-classifier, parameter-binder, and plan-parser suites: TEST SUCCEEDED.
  • swift test --package-path Packages/TableProCore: 181 tests passed.
  • swiftlint lint --strict: clean.

The aggregate TablePro test run reached unrelated existing failures and then stalled in MCPBridgeIntegrationTests.BridgeHarness.startReader(); all DM8 and changed-area suites pass. SwiftFormat 0.62.1 cannot read the repository's existing legacy --ifdefindent option.

Closes #1671
Closes #2010
Related to #2003

@sophiathedev
sophiathedev marked this pull request as ready for review August 16, 2026 03:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d541700175

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +699 to +702
// On DML error, send ROLLBACK to clean up connection state
if !has_result_set {
let _ = self.rollback();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep explicit transactions active after statement errors

When a DML statement fails inside an explicit transaction, this path calls rollback(), which rolls back the whole transaction and resets auto_commit to true. If the caller catches the statement error and continues, the next successful write is therefore committed immediately, and a later explicit rollback cannot undo it; return the statement error without silently changing transaction state.

Useful? React with 👍 / 👎.

Comment on lines +1288 to +1290
// COMMIT may also invalidate the server-side statement handle.
// Reset to 0 so the next execute() will allocate a fresh one.
self.handle = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the connection handle after commit

After an explicit commit, this clears self.handle, even though that field was populated from login_resp.session_id and is subsequently used by FETCH, LOB, and keepalive messages. A later result requiring fetch_more or LOB retrieval is then sent with handle 0 and can fail despite the client remaining in Ready; rollback repeats the same reset, so neither transaction completion path should discard the session handle.

Useful? React with 👍 / 👎.

Comment on lines +1158 to +1160
if let Ok(s) = std::str::from_utf8(data) {
if let Ok(d) = rust_decimal::Decimal::from_str(s.trim()) {
return Some(DmValue::Decimal(d));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve high-precision decimal values as text

For DECIMAL/NUMERIC values outside rust_decimal's 96-bit representable range, parsing the already textual value fails and the binary fallback also fails because it receives ASCII data. row.get consequently returns None, which the bridge converts to SQL NULL, silently corrupting displayed and exported high-precision values; keep the decoded decimal text instead of requiring it to fit rust_decimal.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin datlechin added the abi-additive PluginKit ABI diff reviewed as additive; no version bump needed label Aug 17, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin

Copy link
Copy Markdown
Member

Review fixes pushed to this branch

I ran a review pass over the driver, the Rust bridge, the C ABI, and the core registry changes, then pushed 5 commits on top of 07c7d406d. Nothing was rebased or force pushed. Summary of what changed and why, so you can check my reasoning.

Data loss

Decode failures became SQL NULL. convert_cell collapsed every unreadable cell into TpDmCell::Null, which is indistinguishable from a real NULL. For a table with no primary key, rowPredicate builds the WHERE clause from every column, so that fake NULL became "col" IS NULL and the UPDATE or DELETE either hit a different row or silently matched nothing. Unreadable cells now come back as their raw bytes, so the save fails loudly instead of writing to the wrong row.

Binary DECIMAL decoded with the wrong value. decode_dm_binary_decimal computed the exponent into _exp and discarded it, dropped the sign, and capped the mantissa at i64. A DECIMAL(12,2) of 42.50 decoded as 4250, -1.23 decoded as 123, and anything wider than about 18 significant digits returned SQL NULL and fed straight into the predicate problem above.

The fix was mostly deletion of a duplicate: decode_dm_decimal_to_text in dameng-protocol/src/message/response.rs:455 already does this correctly and has four captured vectors pinning it. The dameng-types copy was a truncated fork of it. I also checked the algorithm against Dameng's own Go driver (dm8/o.go decodeDecimal) and it agrees byte for byte.

Two related things fell out of that. The call site's if s.is_ascii() { return None } was swallowing every negative binary decimal before the decoder ran, because a negative varnum is pure ASCII. And once that guard was removed, the decoder was permissive enough to turn text like 12.3.4 into a plausible number, so it now validates strictly against the vendor rule that a short negative value must carry the 0x66 terminator.

Packed DATE, TIME and TIMESTAMP rendered as control characters. The arm returned DmValue::Text for any payload that happened to be valid UTF-8, before any typed decode ran. A packed 10:08:45 is 0a 69 01 00 00, every byte below 0x80, so it took that path and reached the grid as mojibake. Decoding now dispatches on wire length the way the vendor driver does (DATE 3, TIME 5, TIME_TZ 7, DATETIME 8, DATETIME2 9, DATETIME_TZ 10, DATETIME2_TZ 11), and text is a validated fallback rather than a passthrough. Worth noting the comment above that code described the layout as hour, min, sec, nanos big-endian, which is not what DM sends. The 8 byte branch already in the file had it right.

View definitions were truncated then written back. fetchViewDefinition cast to VARCHAR(8188) with no truncation signal, and the driver also offers CREATE OR REPLACE VIEW, so opening a long view and saving replaced it with the truncated half. It now asks the server for LENGTHB(TEXT) and refuses rather than guessing from the returned length, which would have rejected a view of exactly 8188 bytes that is actually complete.

DROP TABLE was emitted for materialized views. dropObjectStatement mapped everything that was not VIEW to TABLE. TableOperationSQLBuilder.dropKeyword can pass MATERIALIZED VIEW, and DM8 supports them, so dropping one from the sidebar could destroy a same-named table. The default branch was the destructive one.

SQL injection

The driver interpolates literals rather than binding parameters, so DamengParameterBinder is the whole boundary.

DM8 supports a MySQL compatible mode where backslash escapes inside string literals. The binder only doubled quotes, so a trailing backslash escaped its own closing quote. It now probes the server once at connect with SELECT LENGTH('\\') FROM DUAL, escapes for the measured mode, and fails closed when it cannot measure. I avoided escaping backslash unconditionally because that would corrupt every Windows path and regex on a default mode server.

Two things I found while fixing that are worth calling out, because they are subtle:

  • String.replacingOccurrences matches whole grapheme clusters, so a quote carrying a combining mark was never doubled. x' followed by U+0301 produced 'x'◌́ OR 1=1 -- ', terminating the literal and executing the rest. Escaping now walks Unicode scalars. This affected the original quote doubling too, not only the new backslash handling.
  • The binder is not the only literal path. The foreign key preview, row actions and exports call escapeStringLiteral, which was inheriting the PluginKit default that only doubles quotes. DamengPluginDriver now overrides it and requiresBackslashEscapingInLiterals.

Row caps

applyingRowCap wrapped user SQL in SELECT * FROM (...) TABLEPRO_ROW_CAP WHERE ROWNUM <= n. Two problems: an inline view rejects duplicate output column names, so any join where both sides have an ID failed once a cap was active, and a WITH clause is not legal at the start of an inline view, so every CTE failed.

I deleted the rewrite rather than patching it, because the cap is already enforced natively. query_result fetches row_cap + 1, sets is_truncated, and truncates to row_cap, and executeBound was already passing rowCap down alongside the rewrite. Deleting it keeps the cap, fixes joins and CTEs, and removes the bespoke lexer. It also stops the rewrite being applied to the driver's own metadata queries.

While there, rowCap of nil reached the bridge as 0 and took an unbounded branch, which is the only driver in Plugins/ with no ceiling. It now clamps to PluginRowLimits.emergencyMax like every peer, and a query that hits that ceiling with no explicit cap raises an error rather than silently truncating an export.

Connection lifecycle

connect() returned success without contacting the server whenever rawConnection was non nil, so after a protocol error killed the client the connection could never be revived and every later call reported "Dameng connection is closed". disconnect() used queue.sync on the same serial queue that blocks for a full network round trip, so closing during a slow query blocked the caller, which on the main actor is a visible hang. Handle state is now behind a lock with an explicit shutdown flag, and teardown is asynchronous.

Also: connect() treated the V$VERSION banner query as fatal. That view is DBA restricted, so a normal application user could not connect at all. It is best effort now, matching OraclePlugin. The live suite only ever runs as SYSDBA, which is why this was not caught.

Explain plan

The registry entry omitted format: .damengText and no .dameng case existed in ExplainPlanFormatDefaults, so the resolver fell through to plain text and DamengPlanParser was never selected. Both are fixed.

The plan parser also derived tree depth from the spaces between the line number and #. DM8 column aligns that marker, so the gap shrinks as the line number widens and the tree reparents. It measures the marker column now. For the record this needs 100+ plan lines to trigger, not 10, because the collision only happens once line numbers reach three digits.

CI

cargo test was not run anywhere, so the bridge's own test had never executed. I added it, and made it cover the vendored crates too. They are path dependencies rather than workspace members, so testing the bridge manifest alone skipped roughly 195 decoder tests. That is how the decimal and datetime bugs shipped.

Verification

  • cargo test across all four crates: 201 passed
  • swiftlint lint --strict repo wide: 0 violations in 1424 files
  • AllPlugins and TablePro builds: succeeded
  • TableProTests targeted suites: 110 passed
  • DamengDriverTests: 9 tests, 0 failures, 2 skipped
  • scripts/check-pluginkit-abi.sh 07c7d406d: unchanged

Each new test was checked to fail on the previous code, not just to pass on the new code.

Two things I could not verify, and one for you

I had no DM8 server, so the 2 integration cases skipped throughout.

  1. The backslash mode probe is unverified against a real server. Please run SELECT LENGTH('\\') FROM DUAL on your instance. It should return 2 in the default mode and 1 in MySQL compatible mode. That single result decides whether the escaper picks the right branch.
  2. The packed date layout is corroborated but not captured. It matches the vendor Go driver, the JDBC dispatch, and the 8 byte branch already in this PR, but there are no captured DM8 bytes in the repo. This settles it in one query:
    SELECT CAST('2024-06-15' AS DATE), CAST('10:30:45.123456' AS TIME),
           CAST('2024-06-15 10:30:45.123456' AS TIMESTAMP) FROM DUAL
    Lengths of 3, 5 and 8 confirm the whole model. If it is wrong, cells render as hex rather than as a wrong date, which is the safe direction.
  3. TIMESTAMP WITH TIME ZONE offsets are validated but discarded, so those values lose their offset. That is a gap rather than corruption, but it should get a changelog line if it ships this way.

I added the abi-additive label, since the ExplainPlanFormat.damengText addition is additive and the gate needs it.

Happy to talk through any of these if you disagree with a call, particularly deleting applyingRowCap and making the version query best effort.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

abi-additive PluginKit ABI diff reviewed as additive; no version bump needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plz support DM8(dameng) Feature: Add support for Dameng (DM) Chinese database

3 participants